/*
 * (c) ralfoide gmail com, 2008
 * Project: gamez/packer
 * License TBD
 */

package com.alfray.gamez.packer;

import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
import java.util.HashMap;
import java.util.Map;

import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.parsers.ParserConfigurationException;
import javax.xml.xpath.XPath;
import javax.xml.xpath.XPathConstants;
import javax.xml.xpath.XPathExpressionException;
import javax.xml.xpath.XPathFactory;

import org.w3c.dom.Document;
import org.w3c.dom.NamedNodeMap;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
import org.xml.sax.SAXException;

import com.alfray.gamez.util.NodesIterator;

/**
 * Collada XML reader.
 * <p/> 
 * Note: package protected classes are used directly in unit-tests.
 * Private classes are not.
 */
public class ColladaReader {

	/** Current XML document to parse */
	private final Document mXmlDoc;
	/** Current XPath evaluator */
	private XPath mXPath;
	/** List of model geometries found so far */
	private ArrayList<Model> mModels = new ArrayList<Model>();
	

	/**
	 * @param osFilename The Collada XML document file name
	 * @return True if document was parsed successfully
	 */
	public static boolean parse(String osFilename) {
		try {
			if (osFilename != null && osFilename.length() > 0) {
				return parse(new FileInputStream(osFilename));
			}
		} catch (FileNotFoundException e) {
			error("ColladaReader: File not found '%s'", osFilename);
		}
		return false;
	}
	
	/**
	 * @param is The Collada XML document input stream
	 * @return True if document was parsed successfully
	 * @throws IllegalArgumentException if input stream is null.
	 */
	public static boolean parse(InputStream is) {
		try {
			DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
			DocumentBuilder builder = factory.newDocumentBuilder();
			Document doc = builder.parse(is);
			
			return new ColladaReader(doc).process();
		} catch (ParserConfigurationException e) {
			e.printStackTrace();
		} catch (SAXException e) {
			e.printStackTrace();
		} catch (IOException e) {
			e.printStackTrace();
		}
		return false;
	}
	
	/**
	 * Creates a ColladaReader for this XML Document.
	 * <p/>
	 * Access is package protected for unit tests.
	 * Clients should use the public static parse() methods.
	 * 
	 * @param xmlDoc The document to parse. Not null.
	 */
	ColladaReader(Document xmlDoc) {
		mXmlDoc = xmlDoc;

		XPathFactory factory = XPathFactory.newInstance();
		mXPath = factory.newXPath();
	}

	/**
	 * Processes the XML document 
	 * @return True if something could get parsed.
	 */
	boolean process() {
		String sceneId = getUrlId(mXmlDoc, "/COLLADA/scene/instance_visual_scene/@url");
		if (sceneId == null) return false;
		
		for (Node node : getElements(mXmlDoc,
				String.format("/COLLADA/library_visual_scenes/visual_scene[@id='%s']//node[instance_geometry]",
						sceneId))) {
			Model model = loadVisualSceneModel(node);
			if (model != null) mModels.add(model);
		}

		return true;
	}
	
	/**
	 * @param node An element from visual_scene//node that contains an instance_geometry
	 * @return A new {@link Model} or null.
	 */
	private Model loadVisualSceneModel(Node node) {
		String id = getAttr(node, "@id");
		String name = getAttr(node, "@name");
		Node instanceGeometry = getElement(node, "instance_geometry");
		String geometryId = getUrlId(instanceGeometry, "@url");

		// Only take care of geometry nodes that have a triangle mesh
		Node geometryNode = getElement(mXmlDoc,
				String.format("/COLLADA/library_geometries/geometry[@id='%s' and mesh/triangles]",
						geometryId));
		if (geometryNode == null) return null;

		String vertexSourceId = getUrlId(geometryNode, "vertices/input[@semantic='POSITION']/@source");
		if (vertexSourceId == null) {
			error(node, "Missing vertices/input[@semantic='POSITION']/@source");
			return null;
		}

		Model model = new Model(id, name);

		Map<String, String> materialSymbols = new HashMap<String, String>();		
		for (Node instanceMaterial : getElements(instanceGeometry,
				"bind_material/tehnique_common/instance_material")) {
			String symbol = getAttr(instanceMaterial, "@symbol");
			String target = getUrlId(instanceMaterial, "@target");
			if (symbol != null && target != null) {
				materialSymbols.put(symbol, target);
			}
		}

		for (Node trianglesNode : getElements(geometryNode, "mesh/triangles")) {
			loadTriangles(model, trianglesNode);
		}
		
		return model;
	}

	
	/**
	 * Parses /COLLADA/library_geometries/geometry[@id]/mesh/triangles
	 */
	private void loadTriangles(Model model, Node trianglesNode) {
		InputInfo[] inputs = loadInputs(trianglesNode);
		inputs.clone();
	}

	/**
	 * Parses mesh/triangles/input.
	 * <p/>
	 * Returns an array where the input info are guaranteed sorted by
	 * increasing offset.
	 */
	private InputInfo[] loadInputs(Node trianglesNode) {
		ArrayList<InputInfo> inputList = new ArrayList<InputInfo>();
		for (Node inputNode : getElements(trianglesNode, "input")) {
			InputInfo info = loadInput(inputNode);
			if (info != null) inputList.add(info);
		}
		InputInfo[] inputs = inputList.toArray(new InputInfo[inputList.size()]);
		Arrays.sort(inputs, new Comparator<InputInfo>() {
			public int compare(InputInfo o1, InputInfo o2) {
				return o1.getOffset() - o2.getOffset();
			}
		});
		return inputs;
	}
	
	/**
	 * Parses one mesh/triangles/input
	 */
	private InputInfo loadInput(Node inputNode) {
		InputInfo.Semantic semantic = null;
		try {
			semantic = InputInfo.Semantic.valueOf(getAttr(inputNode, "semantic"));
			String sourceId = getUrlId(inputNode, "source");
			int offset = Integer.parseInt(getAttr(inputNode, "offset"));
			
			InputInfo ii = new InputInfo(semantic, sourceId, offset);
			Node sourceNode = getElement(inputNode.getParentNode().getParentNode(), // mesh node
					String.format("source[@id='%s'", sourceId));
			if (sourceNode != null) {
				if (loadInputSource(ii, sourceNode)) {
					return ii;
				}
			} else {
				error(inputNode, "Missing source '%s' for input '%s'",
						sourceId, inputNode.toString());
			}
			
		} catch (NumberFormatException e) {
			error(inputNode, "Invalid offset: %s", e.toString());
		} catch (IllegalArgumentException e) {
			error(inputNode, "Invalid semantic '%s': %s",
					semantic == null ? "?" : semantic.toString(), e.toString());
		}
		return null;
	}

	/**
	 * Parses one mesh/source and stores in InputInfo.
	 */
	boolean loadInputSource(InputInfo ii, Node sourceNode) {
		Node floatArrayNode = getElement(sourceNode, "float_array");
		if (floatArrayNode == null) {
			return error(sourceNode, "Missing float_array");
		}

		Node accessor = getElement(sourceNode, "tehnique_common/accessor");
		try {
			int total_count = Integer.parseInt(getAttr(floatArrayNode, "@count"));
			int items = Integer.parseInt(getAttr(accessor, "@count"));
			int stride = Integer.parseInt(getAttr(accessor, "@stride"));
	
			if (total_count != items * stride) {
				return error(accessor, String.format("Source mismatch: total %d != items %d * stride %d",
						total_count, items, stride));
			}
			
			String text = floatArrayNode.getTextContent();
			String[] values = text.split("\\s+");
			
			if (values.length != total_count) {
				return error(accessor, String.format("Source mismatch: %d split values for total %d",
						values.length, total_count));
			}
			
			float[] floats = new float[total_count];
			int index = 0;
			for (String v : values) {
				floats[index++] = Float.parseFloat(v.trim());
			}
	
			ii.setSource(floats, stride);
			return true;
		} catch (NumberFormatException e) {
			return error(accessor, "Invalid number: %s", e.toString());
		}
	}

	static class InputInfo {
		private final Semantic mSemantic;
		private final String mSourceId;
		private final int mOffset;
		private float[] mSourceValues;
		private int mSourceStride;

		public enum Semantic {
			VERTEX,
			NORMAL,
			TEXCOORD
		}
		
		public InputInfo(Semantic semantic, String sourceId, int offset) {
			mSemantic = semantic;
			mSourceId = sourceId;
			mOffset = offset;
		}

		public void setSource(float[] values, int stride) {
			mSourceValues = values;
			mSourceStride = stride;
		}

		public Semantic getSemantic() {
			return mSemantic;
		}

		public String getSourceId() {
			return mSourceId;
		}

		public int getOffset() {
			return mOffset;
		}
		
		public float[] getSourceValues() {
			return mSourceValues;
		}
		
		public int getSourceStride() {
			return mSourceStride;
		}
	}

	//------------------------------------------------------------------
	// XPath helpers
	//------------------------------------------------------------------
	
	
	/**
	 * Returns the string for the given XML attribute or null
	 * 
	 * @param root The root of the XPath. Either a Document or a sub-Node.
	 * @param xpath The XPath expression that selects an attribute (e.g. "//@id")
	 * @return Returns the string for the given XML attribute or null.
	 */
	String getAttr(Node root, String xpathExpr) {
		String result = null;
		try {
			if (xpathExpr.startsWith("@") && xpathExpr.indexOf('/') == -1) {
				// optimize for the simple case of extracting an attribute
				// directly in a root node.
				Node attr = root.getAttributes().getNamedItem(xpathExpr.substring(1));
				if (attr != null) {
					return attr.getNodeValue();
				}
			}
			result = (String) mXPath.evaluate(xpathExpr, root, XPathConstants.STRING);
		} catch (XPathExpressionException e) {
			e.printStackTrace();
		}
		return result;
	}
	
	/**
	 * Returns the local id pointed to by the given XML attribute (XPointer).
	 * A local URL is in the form "#id" and this returns "id" or null.
	 * 
	 * @param root The root of the XPath. Either a Document or a sub-Node.
	 * @param xpath The XPath expression that selects an attribute (e.g. "//@id")
	 * @return Returns the id pointed to by the given XML attribute or null.
	 */
	String getUrlId(Node root, String xpathExpr) {
		String result = getAttr(root, xpathExpr);
		if (result != null && result.startsWith("#")) {
			return result.substring(1);
		}
		return null;
	}
	
	/**
	 * Returns a single element selected using an XPath expression.
	 * <p/>
	 * If the expression were to select multiple elements, only the first
	 * one is returned.
	 * If the expression does not select any element, returns null.
	 * 
	 * @param root The root of the XPath. Either a Document or a sub-Node.
	 * @param xpath The XPath expression that select one or more elements.
	 * @return A new {@link Node} on the first element selected or null.
	 */
	Node getElement(Node root, String xpathExpr) {
		Node node = null;
		try {
			node = (Node) mXPath.evaluate(xpathExpr, root, XPathConstants.NODE);
		} catch (XPathExpressionException e) {
			e.printStackTrace();
		}
		return node;
	}
	
	/**
	 * Returns a nodes iterator after selecting a group of elements via an
	 * XPath expression.
	 * 
	 * @param root The root of the XPath. Either a Document or a sub-Node.
	 * @param xpath The XPath expression that selects a group of element.
	 * @return A new {@link NodesIterator}, it may be empty or even wrap null.
	 */
	NodesIterator getElements(Node root, String xpathExpr) {
		NodeList list = null;
		try {
			list = (NodeList) mXPath.evaluate(xpathExpr, root, XPathConstants.NODESET);
		} catch (XPathExpressionException e) {
			e.printStackTrace();
		}
		return new NodesIterator(list);
	}
	
	private static boolean error(String format, Object ... params) {
		System.err.println(String.format(format, params));
		return false;
	}

	private static boolean error(Node node, String format, Object ... params) {
		StringBuilder sb = new StringBuilder();
		StringBuilder sb2 = new StringBuilder();
		if (node == null) {
			sb.append("(node null)");
		} else {
			for (; node != null && node.getNodeType() != Node.DOCUMENT_NODE;
			       node = node.getParentNode()) {
				sb2.delete(0, sb2.length());
				sb2.append(node.getNodeName());
				NamedNodeMap attrs = node.getAttributes();
				Node attr = attrs.getNamedItem("id");
				if (attr == null) attr = attrs.getNamedItem("name");
				if (attr == null) attr = attrs.getNamedItem("semantic");
				if (attr != null) {
					sb2.append("[@").append(attr.getNodeName()).append("='");
					sb2.append(attr.getNodeValue()).append("']");
				}

				if (sb.length() > 0) {
					sb.insert(0, " > ");
				}
				sb.insert(0, sb2);
			}
		}
		sb.append(": ").append(format);
		
		return error(sb.toString(), params);
	}

}
